/**********************************************************************

  Audacity: A Digital Audio Editor

  Lyrics.cpp

  Dominic Mazzoni
  Vaughan Johnson

**********************************************************************/

#include "Lyrics.h"

#include <cmath>

#include <wx/dcclient.h>
#include <wx/defs.h>
#include <wx/dcmemory.h>
#include <wx/frame.h>
#include <wx/mimetype.h>

#include "AudioIO.h"
#include "Project.h"
#include "ProjectWindowBase.h"
#include "LabelTrack.h"
#include "commands/CommandManager.h"
#include "UndoManager.h"
#include "ViewInfo.h"

BEGIN_EVENT_TABLE(HighlightTextCtrl, wxTextCtrl)
EVT_MOUSE_EVENTS(HighlightTextCtrl::OnMouseEvent)
END_EVENT_TABLE()

HighlightTextCtrl::HighlightTextCtrl(LyricsPanel *parent,
                                     wxWindowID id,
                                     const wxString &value /* = {} */,
                                     const wxPoint &pos /*= wxDefaultPosition*/,
                                     const wxSize &size /*= wxDefaultSize*/)
    : wxTextCtrl(parent, id,                                                                                // wxWindow* parent, wxWindowID id,
                 value,                                                                                     // const wxString& value = {},
                 pos,                                                                                       // const wxPoint& pos = wxDefaultPosition,
                 size,                                                                                      // const wxSize& size = wxDefaultSize,
                 wxTE_MULTILINE | wxTE_READONLY | wxTE_RICH | wxTE_RICH2 | wxTE_AUTO_URL | wxTE_NOHIDESEL), // v | wxHSCROLL)
      mLyricsPanel(parent)
{
}

void HighlightTextCtrl::OnMouseEvent(wxMouseEvent &event)
{
    if (event.ButtonUp())
    {
        long from, to;
        this->GetSelection(&from, &to);

        int nCurSyl = mLyricsPanel->GetCurrentSyllableIndex();
        int nNewSyl = mLyricsPanel->FindSyllable(from);
        if (nNewSyl != nCurSyl)
        {
            Syllable *pCurSyl = mLyricsPanel->GetSyllable(nNewSyl);
            auto pProj = FindProjectFromWindow(this);
            auto &selectedRegion = ViewInfo::Get(*pProj).selectedRegion;
            selectedRegion.setT0(pCurSyl->t);

            // v Should probably select to end as in
            //  SelectUtilities::Handler::OnSelectCursorEnd,
            //  but better to generalize that in TenacityProject methods.
            selectedRegion.setT1(pCurSyl->t);
        }
    }

    event.Skip();
}

LyricsPanel::LyricsPanel(wxWindow *parent, wxWindowID id,
                         TenacityProject *project,
                         const wxPoint &pos /*= wxDefaultPosition*/,
                         const wxSize &size /*= wxDefaultSize*/) : wxPanelWrapper(parent, id, pos, size, wxWANTS_CHARS),
                                                                   mWidth(size.x), mHeight(size.y), mProject(project)
{
    Bind(wxEVT_KEY_DOWN, &LyricsPanel::OnKeyEvent, this);
    Bind(wxEVT_PAINT, &LyricsPanel::OnPaint, this);
    Bind(wxEVT_SIZE, &LyricsPanel::OnSize, this);

    mKaraokeHeight = mHeight;
    mLyricsStyle = kBouncingBallLyrics;            // default
    mKaraokeFontSize = this->GetDefaultFontSize(); // Call only after mLyricsPanelStyle is set.

    this->SetBackgroundColour(*wxWHITE);

    mHighlightTextCtrl =
        safenew HighlightTextCtrl(this, -1,      // wxWindow* parent, wxWindowID id,
                                  wxT(""),       // const wxString& value = {},
                                  wxPoint(0, 0), // const wxPoint& pos = wxDefaultPosition,
                                  size);         // const wxSize& size = wxDefaultSize
    this->SetHighlightFont();
    mHighlightTextCtrl->Show(mLyricsStyle == kHighlightLyrics); // test, in case we conditionalize the default, above

    mT = 0.0;

    Clear();
    Finish(0.0);

#ifdef __WXMAC__
    wxSizeEvent dummyEvent;
    OnSize(dummyEvent);
#endif

    parent->Bind(wxEVT_SHOW, &LyricsPanel::OnShow, this);

    project->Bind(EVT_UNDO_PUSHED, &LyricsPanel::UpdateLyrics, this);
    project->Bind(EVT_UNDO_MODIFIED, &LyricsPanel::UpdateLyrics, this);
    project->Bind(EVT_UNDO_OR_REDO, &LyricsPanel::UpdateLyrics, this);
    project->Bind(EVT_UNDO_RESET, &LyricsPanel::UpdateLyrics, this);

    mSubscription = AudioIO::Get()->Subscribe(*this, &LyricsPanel::OnStartStop);
}

LyricsPanel::~LyricsPanel()
{
}

#define I_FIRST_REAL_SYLLABLE 2

void LyricsPanel::Clear()
{
    mSyllables.clear();
    mText = wxT("");

    // Add two dummy syllables at the beginning
    mSyllables.push_back(Syllable());
    mSyllables[0].t = -2.0;
    mSyllables.push_back(Syllable());
    mSyllables[1].t = -1.0;

    mHighlightTextCtrl->Clear();
}

void LyricsPanel::AddLabels(const LabelTrack *pLT)
{
    const size_t numLabels = pLT->GetNumLabels();
    wxString highlightText;
    for (size_t ii = 0; ii < numLabels; ++ii)
    {
        const LabelStruct *const pLabel = pLT->GetLabel(ii);
        Add(pLabel->getT0(), pLabel->title, highlightText);
    }
    mHighlightTextCtrl->AppendText(highlightText);
}

void LyricsPanel::Add(double t, const wxString &syllable, wxString &highlightText)
{
    int i = mSyllables.size();

    {
        Syllable &prevSyllable = mSyllables[i - 1];

        if (prevSyllable.t == t)
        {
            // We can't have two syllables with the same time, so append
            // this to the end of the previous one if they're at the
            // same time.
            prevSyllable.text += syllable;
            prevSyllable.textWithSpace += syllable;
            prevSyllable.char1 += syllable.length();
            return;
        }
    }

    mSyllables.push_back(Syllable());
    Syllable &thisSyllable = mSyllables[i];
    thisSyllable.t = t;
    thisSyllable.text = syllable;

    thisSyllable.char0 = mText.length();

    // Put a space between syllables unless the previous one
    // ended in a hyphen
    if (i > 0 &&
        // mSyllables[i-1].text.length() > 0 &&
        mSyllables[i - 1].text.Right(1) != wxT("-"))
        thisSyllable.textWithSpace = wxT(" ") + syllable;
    else
        thisSyllable.textWithSpace = syllable;

    mText += thisSyllable.textWithSpace;
    thisSyllable.char1 = mText.length();

    int nTextLen = thisSyllable.textWithSpace.length();
    if ((nTextLen > 0) && (thisSyllable.textWithSpace.Right(1) == wxT("_")))
        highlightText += (thisSyllable.textWithSpace.Left(nTextLen - 1) + wxT("\n"));
    else
        highlightText += thisSyllable.textWithSpace;
}

void LyricsPanel::Finish(double finalT)
{
    // Add 3 dummy syllables at the end
    int i = mSyllables.size();
    mSyllables.push_back(Syllable());
    mSyllables[i].t = finalT + 1.0;
    mSyllables.push_back(Syllable());
    mSyllables[i + 1].t = finalT + 2.0;
    mSyllables.push_back(Syllable());
    mSyllables[i + 2].t = finalT + 3.0;

    // Mark measurements as invalid
    mMeasurementsDone = false; // only for drawn text
    mCurrentSyllable = 0;
    mHighlightTextCtrl->ShowPosition(0);
}

// Binary-search for the syllable whose char0 <= startChar <= char1.
int LyricsPanel::FindSyllable(long startChar)
{
    int i1, i2;

    i1 = 0;
    i2 = mSyllables.size();
    while (i2 > i1 + 1)
    {
        int pmid = (i1 + i2) / 2;
        if (mSyllables[pmid].char0 > startChar)
            i2 = pmid;
        else
            i1 = pmid;
    }

    if (i1 < 2)
        i1 = 2;
    if (i1 > (int)(mSyllables.size()) - 3)
        i1 = mSyllables.size() - 3;

    return i1;
}

void LyricsPanel::SetLyricsStyle(const LyricsStyle newLyricsStyle)
{
    if (mLyricsStyle == newLyricsStyle)
        return;

    mLyricsStyle = newLyricsStyle;
    mHighlightTextCtrl->Show(mLyricsStyle == kHighlightLyrics);

    wxSizeEvent ignore;
    this->OnSize(ignore);
}

unsigned int LyricsPanel::GetDefaultFontSize() const
{
    return (mLyricsStyle == kBouncingBallLyrics) ? 48 : 10;
}

void LyricsPanel::SetDrawnFont(wxDC *dc)
{
    dc->SetFont(wxFont(mKaraokeFontSize, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL));
}

void LyricsPanel::SetHighlightFont() // for kHighlightLyrics
{
    wxFont newFont(mKaraokeFontSize, wxFONTFAMILY_SWISS, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL);
    mHighlightTextCtrl->SetDefaultStyle(wxTextAttr(wxNullColour, wxNullColour, newFont));
    mHighlightTextCtrl->SetStyle(0, mHighlightTextCtrl->GetLastPosition(),
                                 wxTextAttr(wxNullColour, wxNullColour, newFont));
}

void LyricsPanel::Measure(wxDC *dc) // only for drawn text
{
    this->SetDrawnFont(dc);
    int width = 0, height = 0;

    const int kIndent = 4;
    int x = 2 * kIndent;

    unsigned int i;
    for (i = 0; i < mSyllables.size(); i++)
    {
        if ((i < I_FIRST_REAL_SYLLABLE) || // Clear() starts the list with I_FIRST_REAL_SYLLABLE dummies.
            (i >= mSyllables.size() - 3))  // Finish() ends with 3 dummies.
        {
            dc->GetTextExtent(wxT("DUMMY"), &width, &height); // Get the correct height even if we're at i=0.
            width = 0;
        }
        else
        {
            dc->GetTextExtent(mSyllables[i].textWithSpace, &width, &height);
        }

        // Add some space between words; the space is normally small but
        // when there's a long pause relative to the previous word, insert
        // extra space.
        int extraWidth;
        if (i >= I_FIRST_REAL_SYLLABLE && i < mSyllables.size() - 2)
        {
            double deltaThis = mSyllables[i + 1].t - mSyllables[i].t;
            double deltaPrev = mSyllables[i].t - mSyllables[i - 1].t;

            double ratio;
            if (deltaPrev > 0.0)
                ratio = deltaThis / deltaPrev;
            else
                ratio = deltaThis;

            if (ratio > 2.0)
                extraWidth = 15 + (int)(15.0 * ratio);
            else
                extraWidth = 15;
        }
        else
            extraWidth = 20;

        mSyllables[i].width = width + extraWidth;
        mSyllables[i].leftX = x;
        mSyllables[i].x = x + width / 2;
        x += mSyllables[i].width;
    }

    mTextHeight = height;

    mMeasurementsDone = true;
}

// Binary-search for the syllable with the largest time not greater than t
int LyricsPanel::FindSyllable(double t)
{
    int i1, i2;

    i1 = 0;
    i2 = mSyllables.size();
    while (i2 > i1 + 1)
    {
        int pmid = (i1 + i2) / 2;
        if (mSyllables[pmid].t > t)
            i2 = pmid;
        else
            i1 = pmid;
    }

    if (i1 < 2)
        i1 = 2;
    if (i1 > (int)(mSyllables.size()) - 3)
        i1 = mSyllables.size() - 3;

    return i1;
}

// Bouncing Ball:
// Given the current time t, returns the x/y position of the scrolling
// karaoke display.  For some syllable i, when t==mSyllables[i].t,
// it will return mSyllables[i].x for outX and 0 for outY.
// In-between words, outX is interpolated using smooth acceleration
// between the two neighboring words, and y is a positive number indicating
// the bouncing ball height
void LyricsPanel::GetKaraokePosition(double t,
                                     int *outX, double *outY)
{
    *outX = 0;
    *outY = 0;

    if (t < mSyllables[I_FIRST_REAL_SYLLABLE].t || t > mSyllables[mSyllables.size() - 3].t)
        return;

    int i0, i1, i2, i3;
    int x0, x1, x2, x3;
    double t0, t1, t2, t3;
    i1 = FindSyllable(t);
    i2 = i1 + 1;

    // Because we've padded the syllables with two dummies at the beginning
    // and end, we know that i0...i3 will always exist.  Also, we've made
    // sure that we don't ever have two of the same time, so t2>t1 strictly.
    //
    //                          t
    //                          \/
    // time:  t0           t1                 t2              t3
    // pos:   x0           x1                 x2              x3
    // index: i0           i1                 i2              i3
    // vel:               vel1               vel2

    i0 = i1 - 1;
    i3 = i2 + 1;

    x0 = mSyllables[i0].x;
    x1 = mSyllables[i1].x;
    x2 = mSyllables[i2].x;
    x3 = mSyllables[i3].x;

    t0 = mSyllables[i0].t;
    t1 = mSyllables[i1].t;
    t2 = mSyllables[i2].t;
    t3 = mSyllables[i3].t;

    double linear_vel0 = (x1 - x0) / (t1 - t0);
    double linear_vel1 = (x2 - x1) / (t2 - t1);
    double linear_vel2 = (x3 - x2) / (t3 - t2);

    // average velocities
    double v1 = (linear_vel0 + linear_vel1) / 2;
    double v2 = (linear_vel1 + linear_vel2) / 2;

    // Solve a cubic equation f(t) = at^3 + bt^2 + ct + d
    // which gives the position x as a function of
    // (t - t1), by constraining f(0), f'(0), f(t2-t1), f'(t2-t1)
    double delta_t = t2 - t1;
    double delta_x = x2 - x1;
    v1 *= delta_t;
    v2 *= delta_t;
    double a = v1 + v2 - 2 * delta_x;
    double b = 3 * delta_x - 2 * v1 - v2;
    double c = v1;
    double d = x1;

    t = (t - t1) / (t2 - t1);
    double xx = a * t * t * t + b * t * t + c * t + d;

    // Unfortunately sometimes our cubic goes backwards.  This is a quick
    // hack to stop that from happening.
    if (xx < x1)
        xx = x1;

    *outX = (int)xx;

    // The y position is a simple cosine curve; the max height is a
    // function of the time.
    double height = t2 - t1 > 4.0 ? 1.0 : sqrt((t2 - t1) / 4.0);
    *outY = height * sin(M_PI * t);
}

void LyricsPanel::Update(double t)
{
    if (t < 0.0)
    {
        // TrackPanel::OnTimer passes gAudioIO->GetStreamTime(), which is -DBL_MAX if !IsStreamActive().
        // In that case, use the selection start time.
        auto pProj = FindProjectFromWindow(this);
        const auto &selectedRegion = ViewInfo::Get(*pProj).selectedRegion;
        mT = selectedRegion.t0();
    }
    else
        mT = t;

    if (mLyricsStyle == kBouncingBallLyrics)
    {
        wxRect karaokeRect(0, 0, mWidth, mKaraokeHeight);
        this->Refresh(false, &karaokeRect);
    }

    int i = FindSyllable(mT);
    if (i == mCurrentSyllable)
        return;

    mCurrentSyllable = i;

    if (mLyricsStyle == kHighlightLyrics)
    {
        mHighlightTextCtrl->SetSelection(mSyllables[i].char0, mSyllables[i].char1);

        // v No trail for now.
        //// Leave a trail behind the selection, by highlighting.
        // if (i == I_FIRST_REAL_SYLLABLE)
        //    // Reset the trail to zero.
        //    mHighlightTextCtrl->SetStyle(0, mHighlightTextCtrl->GetLastPosition(), wxTextAttr(wxNullColour, *wxWHITE));
        //// Mark the trail for mSyllables[i].
        // mHighlightTextCtrl->SetStyle(mSyllables[i].char0, mSyllables[i].char1, wxTextAttr(wxNullColour, *wxLIGHT_GREY));

        // v Too much flicker:   mHighlightTextCtrl->ShowPosition(mSyllables[i].char0);
    }
}

void LyricsPanel::UpdateLyrics(wxEvent &e)
{
    e.Skip();
    UpdateLyrics();
}

void LyricsPanel::UpdateLyrics()
{
    // It's crucial to not do that repopulating during playback.
    auto gAudioIO = AudioIOBase::Get();
    if (gAudioIO->IsStreamActive())
    {
        mDelayedUpdate = true;
        return;
    }

    Clear();

    if (!mProject)
        return;

    // Lyrics come from only the first label track.
    auto pLabelTrack =
        *TrackList::Get(*mProject).Any<const LabelTrack>().begin();
    if (!pLabelTrack)
        return;

    // The code that updates the lyrics is rather expensive when there
    // are a lot of labels.
    // So - bail out early if the lyrics window is not visible.
    // We will later force an update when the lyrics window is made visible.
    auto parent = dynamic_cast<wxFrame *>(GetParent());
    if (!(parent && parent->IsVisible()))
        return;

    AddLabels(pLabelTrack);
    Finish(pLabelTrack->GetEndTime());
    const auto &selectedRegion = ViewInfo::Get(*mProject).selectedRegion;
    Update(selectedRegion.t0());
}

void LyricsPanel::OnStartStop(AudioIOEvent e)
{
    if (e.type == AudioIOEvent::MONITOR) return;

    if (!e.on && mDelayedUpdate)
    {
        mDelayedUpdate = false;
        UpdateLyrics();
    }
}

void LyricsPanel::OnShow(wxShowEvent &e)
{
    e.Skip();
    if (e.IsShown())
        UpdateLyrics(e);
}

void LyricsPanel::OnKeyEvent(wxKeyEvent &event)
{
    auto project = FindProjectFromWindow(this);
    auto &commandManager = CommandManager::Get(*project);
    commandManager.FilterKeyEvent(project, event, true);
    event.Skip();
}

void LyricsPanel::OnPaint(wxPaintEvent & /* event */)
{
    wxPaintDC dc(this);
    DoPaint(dc);
}

void LyricsPanel::DoPaint(wxDC &dc)
{
    if (!this->GetParent()->IsShown())
        return;

    if (mLyricsStyle == kBouncingBallLyrics)
    {
        if (!mMeasurementsDone)
            Measure(&dc);

#ifdef __WXMAC__
        // Mac OS X automatically double-buffers the screen for you,
        // so our bitmap is unnecessary
        HandlePaint(dc);
#else
        wxBitmap bitmap(mWidth, mKaraokeHeight);
        wxMemoryDC memDC;
        memDC.SelectObject(bitmap);
        HandlePaint(memDC);
        dc.Blit(0, 0, mWidth, mKaraokeHeight, &memDC, 0, 0, wxCOPY, FALSE);
#endif
    }
    else // (mLyricsStyle == kHighlightLyrics)
    {
        // v causes flicker in ported version
        //     this->SetHighlightFont();
    }
}

void LyricsPanel::OnSize(wxSizeEvent & /* event */)
{
    GetClientSize(&mWidth, &mHeight);

    mKaraokeHeight = mHeight;

    mKaraokeFontSize =
        (int)((float)(this->GetDefaultFontSize() * mHeight) / (float)LYRICS_DEFAULT_HEIGHT);
    // Usually don't get the size window we want, usually less than
    // LYRICS_DEFAULT_HEIGHT, so bump it a little.
    mKaraokeFontSize += 2;

    if (mLyricsStyle == kBouncingBallLyrics)
    {
        mMeasurementsDone = false;
        wxClientDC dc(this);
        this->DoPaint(dc);
    }
    else // (mLyricsStyle == kHighlightLyrics)
    {
        mHighlightTextCtrl->SetSize(mWidth, mKaraokeHeight);
        this->SetHighlightFont();
    }

    this->Refresh(false);
}

// v Doesn't seem to be a way to capture a selection event in a read-only wxTextCtrl.
// void LyricsPanel::OnHighlightTextCtrl(wxCommandEvent & event)
//{
//    long from, to;
//
//    mHighlightTextCtrl->GetSelection(&from, &to);
//    // TODO: Find the start time of the corresponding syllable and set playback to start there.
// }

void LyricsPanel::HandlePaint(wxDC &dc)
{
    wxASSERT(mLyricsStyle == kBouncingBallLyrics);
    dc.SetBrush(*wxWHITE_BRUSH);
    dc.DrawRectangle(0, 0, mWidth, mKaraokeHeight);

    this->HandlePaint_BouncingBall(dc);
}

void LyricsPanel::HandlePaint_BouncingBall(wxDC &dc)
{
    int ctr = mWidth / 2;
    int x;
    double y;
    GetKaraokePosition(mT, &x, &y);

    dc.SetTextForeground(wxColour(238, 0, 102));
    bool changedColor = false;

    SetDrawnFont(&dc);
    unsigned int i;
    wxCoord yTextTop = mKaraokeHeight - mTextHeight - 4;
    for (i = 0; i < mSyllables.size(); i++)
    {
        if (mSyllables[i].x + mSyllables[i].width < (x - ctr))
            continue;
        if (mSyllables[i].x > x + ctr)
            continue;

        if (!changedColor && mSyllables[i].x >= x)
        {
            dc.SetTextForeground(*wxBLACK);
            changedColor = true;
        }

        wxString text = mSyllables[i].text;
        if (text.length() > 0 && text.Right(1) == wxT("_"))
        {
            text = text.Left(text.length() - 1);
        }

        dc.DrawText(text,
                    mSyllables[i].leftX + ctr - x,
                    yTextTop);
    }

    int ballRadius = (int)(mTextHeight / 8.0);
    int bounceTop = ballRadius * 2;
    int bounceHeight = yTextTop - bounceTop;
    int yi = (int)(yTextTop - 4 - (y * bounceHeight));

    if (mT >= 0.0)
    {
        wxRect ball(ctr - ballRadius, yi - ballRadius, 2 * ballRadius, 2 * ballRadius);
        dc.SetBrush(wxBrush(wxColour(238, 0, 102), wxBRUSHSTYLE_SOLID));
        dc.DrawEllipse(ball);
    }
}
